Passed
Push — main ( da0f78...e10de5 )
by Pedro
02:22
created

Format.supportedLocalesOf   A

Complexity

Conditions 2

Size

Total Lines 18
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 6
dl 0
loc 18
ccs 1
cts 1
cp 1
rs 10
c 0
b 0
f 0
cc 2
crap 2
1
/*
2
 * decimal.js-i18n v0.2.6
3
 * Full internationalization support for decimal.js.
4
 * MIT License
5
 * Copyright (c) 2022 Pedro José Batista <[email protected]>
6
 * https://github.com/pjbatista/decimal.js-i18n
7
 */
8 1
import Decimal from "decimal.js";
9
import type BaseFormatOptions from "./baseOptions";
10
import type FormatCompactDisplay from "./compactDisplay";
11 1
import { BIGINT_MODIFIERS, ECMA_LIMIT, LOCALES, PLAIN_MODIFIERS } from "./constants";
12
import type FormatCurrency from "./currency";
13
import type FormatCurrencyDisplay from "./currencyDisplay";
14
import type FormatCurrencySign from "./currencySign";
15
import type FormatLocale from "./locale";
16
import type FormatLocaleMatcher from "./localeMatcher";
17
import type FormatNotation from "./notation";
18
import type FormatNumberingSystem from "./numberingSystem";
19
import type FormatOptions from "./options";
20 1
import { extend, resolve, toEcma, validate } from "./options";
21
import type FormatPart from "./part";
22 1
import { exponents, fractions, integerGroups, integers, PartValue } from "./part";
23
import type FormatPartTypes from "./partTypes";
24
import type ResolvedFormatOptions from "./resolvedFormatOptions";
25
import type FormatSignDisplay from "./signDisplay";
26
import type FormatStyle from "./style";
27
import type FormatTrailingZeroDisplay from "./trailingZeroDisplay";
28
import type FormatUnit from "./unit";
29
import type FormatUnitDisplay from "./unitDisplay";
30
import type FormatUseGrouping from "./useGrouping";
31
32
// Calculates an exponential value using base₁₀
33 1
const defaultLocales = LOCALES.slice();
34
35 1
const concatenate = <T extends PartValue>(filter: T[] | ((p: T) => boolean), parts: T[] = []) => {
36 82582
    if (typeof filter === "function") {
37 82568
        parts = parts.filter(filter);
38
    } else {
39 14
        parts = filter;
40
    }
41
42 82582
    return parts.map(p => p.value).join("");
43
};
44
45 5520
const pow10 = (exponent: Decimal.Value) => Decimal.pow(10, exponent);
46
47
/**
48
 * The `Decimal.Format` object enables language-sensitive decimal number formatting. It is entirely based on
49
 * `Intl.NumberFormat`, with the options of the latter being 100% compatible with it.
50
 *
51
 * This class, however, extend the numeric digits constraints of `Intl.NumberFormat` from 21 to 1000000000 in
52
 * order to fully take advantage of the arbitrary-precision of `decimal.js`.
53
 *
54
 * @template TNotation Numeric notation of formatting.
55
 * @template TStyle Numeric style of formatting.
56
 */
57 1
export class Format<TNotation extends FormatNotation = "standard", TStyle extends FormatStyle = "decimal"> {
58 1
    static readonly [Symbol.toPrimitive] = Format;
59 1169
    readonly [Symbol.toStringTag] = "Decimal.Format";
60
61
    /**
62
     * Formats a number according to the locale and formatting options of this {@link Format} object.
63
     *
64
     * @param value A valid [Decimal.Value](https://mikemcl.github.io/decimal.js/#decimal) to format.
65
     * @returns Formatted localized string.
66
     */
67
    readonly format: (value: Decimal.Value) => string;
68
69
    /**
70
     * Allows locale-aware formatting of strings produced by `Decimal.Format` formatters.
71
     *
72
     * @param value A valid [Decimal.Value](https://mikemcl.github.io/decimal.js/#decimal) to format.
73
     * @returns An array of objects containing the formatted number in parts.
74
     */
75
    readonly formatToParts: (value: Decimal.Value) => FormatPart[];
76
77
    /**
78
     * Returns a new object with properties reflecting the locale and number formatting options computed during
79
     * initialization of this {@link Decimal.Format} object.
80
     *
81
     * @returns A new object with properties reflecting the locale and number formatting options computed
82
     *   during the initialization of this object.
83
     */
84
    readonly resolvedOptions: () => ResolvedFormatOptions<TNotation, TStyle>;
85
86
    /**
87
     * Creates a new instance of the `Decimal.Format` object.
88
     *
89
     * @param locales A string with a [BCP 47](https://www.rfc-editor.org/info/bcp47) language tag, or an array
90
     *   of such strings.
91
     *
92
     *   For the general form and interpretation of this parameter, see the [Intl page on
93
     *   MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl).
94
     * @param options Object used to configure the behavior of the string localization.
95
     * @throws `RangeError` when an invalid option is given.
96
     */
97
    constructor(locales?: FormatLocale | FormatLocale[], options?: FormatOptions<TNotation, TStyle>) {
98 1169
        options ??= {};
99
100
        // 1. Check if options do not extrapolate the limits of decimal.js
101 1169
        const valid = validate(options);
102
103 1169
        if (valid !== true) {
104
            // -> it will either be exactly true or contain an array with all faulty properties:
105 5
            throw new RangeError(`${valid.join()} value${valid.length === 1 ? " is" : "s are"} out of range."`);
106
        }
107
108
        // 2. Create a baseline native formatter native
109 1164
        const ecmaOptions = toEcma(options);
110 1164
        const ecmaFormat = new Intl.NumberFormat(locales, ecmaOptions);
111
112
        // 3. Resolve this object's options, using the native resolution as a baseline
113 1164
        const resolved = resolve(options, ecmaFormat.resolvedOptions());
114 1164
        const { minimumIntegerDigits: minID, notation, rounding, style } = resolved;
115
116
        // 4. Create two auxiliary formatters:
117
        // One for the integer part, which can have up to a billion minimum digits...
118 1164
        const bigintOptions = extend(ecmaOptions, BIGINT_MODIFIERS);
119 1164
        const bigintFormat = new Intl.NumberFormat(locales, bigintOptions);
120
121
        // ...and another for a plain, localized reference, used for decimals and constants
122 1164
        const plainOptions = extend(bigintOptions, PLAIN_MODIFIERS);
123 1164
        const plainFormat = new Intl.NumberFormat(locales, plainOptions);
124
125
        // 5. Localized numeric constants
126 1164
        const numbers = Array(10)
127
            .fill(null)
128 11640
            .map((_, index) => plainFormat.format(index));
129 1164
        const numberMatch = new RegExp("[" + numbers.join("") + "]", "g");
130 1164
        const minusSign = /−/gu;
131
132
        // 5.1. Localized zero and one used in substitutions
133 1164
        const [zero, one] = numbers;
134
135
        // 5.2. Helper functions
136 4590
        const indexOfValue = (value: string) => numbers.indexOf(value).toString();
137 4584
        const convert = (text: string) => text.replaceAll(numberMatch, indexOfValue).replaceAll(minusSign, "-");
138 1164
        const zeroFill = (size: number) => Array(size).fill(zero).join("");
139 1164
        const zeroTrim = (text: string) => {
140 20642
            let result = text;
141
142 20642
            while (result[0] === zero && result.length > 1) {
143 335
                result = result.substring(1);
144
            }
145
146 20642
            return result;
147
        };
148
149
        // #region Step 6. Main format method - - - - - - - - - - - - - - - - - - - - - - - - - - - -
150 1164
        const _formatToParts = (value: Decimal.Value) => {
151 25226
            value = new Decimal(value);
152 25226
            const sign = value.s;
153
154
            // 6.1. Create a baseline part array
155 25226
            const ecmaParts = ecmaFormat.formatToParts(value.toNumber());
156
157
            // -> if the value is non-numeric or an infinity, the baseline is good enough
158 25226
            if ((value.isFinite && !value.isFinite()) || (value.isNaN && value.isNaN())) {
159 4584
                return ecmaParts;
160
            }
161
162
            // 6.2. Splitting the parts for easier assembly
163 20642
            const ecmaExponentValue = concatenate(exponents, ecmaParts) || "0";
164 20642
            const ecmaIntegerParts = ecmaParts.filter(integerGroups);
165 20642
            const ecmaIntegerTrimmed = zeroTrim(concatenate(integers, ecmaIntegerParts));
166 20642
            const ecmaIntegerDigits = concatenate(integers, ecmaIntegerParts).length;
167 20642
            const ecmaIntegerTrimmedDigits = ecmaIntegerTrimmed.length;
168 20642
            const ecmaFractionValue = concatenate(fractions, ecmaParts);
169 20642
            const ecmaFractionDigits = ecmaFractionValue.length;
170
171
            // 6.3. Shifting exponents according to notation/style
172
173
            // 6.3.1. Compact notation: calculate the shift in integer digits, and therefore exponent
174 20642
            if (notation === "compact" && !value.eq(0)) {
175 3664
                const baseInteger = value.abs().trunc().toFixed();
176 3664
                const baseIntegerDigits = baseInteger.length;
177 3664
                const correctionDigits = baseIntegerDigits - ecmaIntegerTrimmedDigits;
178
179 3664
                if (correctionDigits > 0) {
180 888
                    value = value.mul(pow10(-correctionDigits));
181
                }
182
            }
183
184
            // 6.3.2. Engr./Scientific notations: evaluate the exponent from the text
185 20642
            if ((notation === "engineering" || notation === "scientific") && ecmaExponentValue !== zero) {
186 4584
                const exponential = new Decimal(convert(ecmaExponentValue));
187 4584
                value = value.mul(pow10(exponential.mul(-1))).abs().mul(sign); // prettier-ignore
188
            }
189
190
            // 6.3.3. Percent style: shift the value accordingly (non numeric parts will remain the same)
191 20642
            if (style === "percent") value = value.mul(100);
192
193
            // 6.4. Parsing the information about the numeric parts
194 20642
            const integer = value.abs().trunc().mul(sign);
195 20642
            const fraction = value.sub(integer).abs();
196 20642
            const integerDigits = !value.eq(0) && integer.eq(0) ? 0 : value.abs().trunc().toFixed().length;
197 20642
            const fractionDigits = value.dp();
198 20642
            const maxSD = resolved.maximumSignificantDigits ?? resolved.maximumFractionDigits! + integerDigits;
199 20642
            const maxFD = resolved.maximumFractionDigits ?? maxSD - integerDigits;
200 20642
            const minSD = resolved.minimumSignificantDigits ?? resolved.minimumFractionDigits! + integerDigits;
201 20642
            const minFD = resolved.minimumFractionDigits ?? minSD - integerDigits;
202
203
            // 6.5. Check for the possibility of the native formatter to have accomplished the desired output
204 20642
            const integerCheck = !ecmaIntegerParts.length || (minID <= ECMA_LIMIT && ecmaIntegerDigits >= minID);
205 20642
            const fractionCheck = !ecmaFractionDigits || (minFD < ECMA_LIMIT && ecmaFractionDigits >= minFD);
206
207
            // -> if the native formatter is good enough for our decimal value, leave it as-is
208 20642
            if (integerCheck && fractionCheck) {
209 20612
                return ecmaParts as FormatPart[];
210
            }
211
212
            // 6.6. Create the integer value
213 30
            const integerParts = (() => {
214 30
                if (integerCheck) return ecmaIntegerParts;
215
216
                // Expanding the integer part
217 19
                const targetDigits = Math.max(integerDigits, minID);
218
219
                // Creates a base 10 power of the target digits
220 19
                const bigint = BigInt(pow10(targetDigits - 1).toFixed());
221
222
                // Format using the bigint formatter and cut it before joining with the ECMA parts
223 19
                const bigintIntegerParts = bigintFormat.formatToParts(bigint).filter(integerGroups);
224
225
                // We need to replace the first 'one' (from the base 10 power) with a 'zero'
226 19
                bigintIntegerParts[0].value = bigintIntegerParts[0].value.replace(new RegExp(one), zero);
227
228
                // Merge the first part with the bigint part
229 19
                ecmaIntegerParts[0].value =
230
                    bigintIntegerParts[ecmaIntegerParts.length - 1].value.slice(0, -ecmaIntegerParts[0].value.length) +
231
                    ecmaIntegerParts[0].value;
232
233 19
                return [...bigintIntegerParts.slice(0, -ecmaIntegerParts.length), ...ecmaIntegerParts];
234
            })();
235
236
            // 6.7. Create the fraction value
237 30
            const fractionValue = (() => {
238 30
                if (fractionCheck) return ecmaFractionValue;
239
240
                // Simpler formatting if there is actually no fraction
241 29
                if (fraction.eq(0)) {
242 13
                    return plainFormat.format(BigInt(pow10(minFD).toFixed())).slice(1);
243
                }
244
245 16
                let suffix = "";
246
247
                // Exponential value of the fraction (converting from decimal to bigint)
248 16
                const exponential = fraction.toDP(maxFD, rounding).mul(pow10(maxFD)).toFixed();
249
250
                // First, create a zero-filled right-side expansion if the digits are insufficient
251 16
                if (fractionDigits < minFD) {
252 15
                    suffix = zeroFill(minFD - maxFD);
253
                }
254
255 16
                const fractionValue = plainFormat.format(BigInt(exponential)) + suffix;
256
257
                // If the value is still not enough, it needs more left-zero-filling
258 16
                if (fractionValue.length < minFD) {
259 2
                    return zeroFill(minFD - fractionValue.length) + fractionValue;
260
                }
261
                // If it's bigger than the maximum, slice it
262 14
                else if (fractionValue.length > maxFD) {
263
                    return fractionValue.slice(0, maxFD);
264
                }
265
266 14
                return fractionValue;
267
            })();
268
269
            // 6.8. Parsing the numeric fragments in a unified part array
270 30
            const result: FormatPart[] = [];
271 30
            let integerDone = false;
272 30
            let fractionDone = false;
273
274 30
            while (ecmaParts.length) {
275 383
                const { type, value } = ecmaParts.shift()!;
276
277 383
                if (type === "integer" || type === "group") {
278 280
                    if (!integerDone) {
279 30
                        integerDone = true;
280 30
                        result.push(...integerParts);
281
                    }
282 280
                    continue;
283
                }
284
285 103
                if (type === "fraction") {
286 29
                    if (!fractionDone) {
287 29
                        fractionDone = true;
288 29
                        result.push({ type, value: fractionValue });
289
                    }
290 29
                    continue;
291
                }
292
293 74
                result.push({ type, value });
294
            }
295 30
            return result;
296
        };
297
        //#endregion
298
299 1164
        this.format = value => concatenate(_formatToParts(value));
300 25212
        this.formatToParts = value => _formatToParts(value);
301 1164
        this.resolvedOptions = () => ({ ...resolved });
302
    }
303
304
    /**
305
     * Returns an array containing the default locales available to the environment, based on a default
306
     * dictionary of locales and regions.
307
     *
308
     * **Note:** This method is non-standard and not available on `Intl` formatters.
309
     *
310
     * @returns Array of strings with the available locales.
311
     */
312
    static supportedLocales(): FormatLocale[] {
313 2
        return Intl.NumberFormat.supportedLocalesOf(defaultLocales);
314
    }
315
316
    /**
317
     * Returns an array containing those of the provided locales that are supported without having to fall back
318
     * to the runtime's default locale.
319
     *
320
     * @template TNotation Numeric notation of formatting.
321
     * @template TStyle Numeric style of formatting.
322
     * @param locales A string with a BCP 47 language tag, or an array of such strings. For the general form
323
     *   and interpretation of the locales argument, see the [Intl page on
324
     *   MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl).
325
     * @param options Object used to configure the behavior of the string localization.
326
     * @returns Array of strings with the available locales.
327
     */
328
    static supportedLocalesOf<TNotation extends FormatNotation = "standard", TStyle extends FormatStyle = "decimal">(
329
        locales: string | string[],
330
        options?: FormatOptions<TNotation, TStyle>,
331
    ) {
332 2
        return Intl.NumberFormat.supportedLocalesOf(locales, options ? toEcma(options) : undefined) as FormatLocale[];
333
    }
334
}
335
// eslint-disable-next-line @typescript-eslint/no-namespace
336
export declare namespace Format {
337
    export type {
338
        BaseFormatOptions,
339
        FormatCompactDisplay,
340
        FormatCurrency,
341
        FormatCurrencyDisplay,
342
        FormatCurrencySign,
343
        FormatLocale,
344
        FormatLocaleMatcher,
345
        FormatNotation,
346
        FormatNumberingSystem,
347
        FormatOptions,
348
        FormatPart,
349
        FormatPartTypes,
350
        ResolvedFormatOptions,
351
        FormatSignDisplay,
352
        FormatStyle,
353
        FormatTrailingZeroDisplay,
354
        FormatUnit,
355
        FormatUnitDisplay,
356
        FormatUseGrouping,
357
    };
358
}
359
export type {
360
    BaseFormatOptions,
361
    FormatCompactDisplay,
362
    FormatCurrency,
363
    FormatCurrencyDisplay,
364
    FormatCurrencySign,
365
    FormatLocale,
366
    FormatLocaleMatcher,
367
    FormatNotation,
368
    FormatNumberingSystem,
369
    FormatOptions,
370
    FormatPart,
371
    FormatPartTypes,
372
    ResolvedFormatOptions,
373
    FormatSignDisplay,
374
    FormatStyle,
375
    FormatTrailingZeroDisplay,
376
    FormatUnit,
377
    FormatUnitDisplay,
378
    FormatUseGrouping,
379
};
380
381
export default Format;
382